A heatmap based on paddling workouts recorded on my Garmin watch
Over the past two years, I’ve logged dozens of paddling workouts with my Garmin watch. I wanted to visualize where I paddled the most in 2024—so I built this interactive heatmap! Here’s what I learned and how I made it:
As any data person will tell you, acquiring the data is usually the most tedious part - and it was. In order to get the data I wanted, I had to log into my Garmin account, filter my activities to water sports, and navigate each individual recorded activity in order to download it as a GPX file. This was in order to preserve the location data associated with each activity, which is not available in the summary .csv available for download.
Once I had an individual file for each activity (87 in total!), I had to aggregate it all into one file. I did this in a separate processing script, so that I could save one small, efficient file to this website and its associated GitHub repo. The code below will not run, it’s just to showcase how I did this processing.
library(sf)
library(xml2)
library(dplyr)
library(purrr)
library(lubridate)
library(jsonlite)
# Define the folder containing GPX files
gpx_folder <- "data/2024 Garmin Data/"
# Get a list of all GPX files in the folder
gpx_files <- list.files(gpx_folder, pattern = "\\.gpx$", full.names = TRUE)
print(gpx_files) # This should show a list of file paths
# Spot-check
gpx_sample <- read_xml(gpx_files[1]) # Read first file to make sure it worked
print(gpx_sample)
# Now use a function to parse all of the GPX files:
extract_gpx_distance <- function(file) {
gpx <- read_xml(file) %>% xml_ns_strip() # Strip namespace
coords <- gpx %>%
xml_find_all("//trkpt") %>%
map_df(~data.frame(
Latitude = as.numeric(xml_attr(.x, "lat")),
Longitude = as.numeric(xml_attr(.x, "lon")),
Timestamp = xml_text(xml_find_first(.x, "time")), # Extract timestamp
File = basename(file) # Keep track of source file
))
if (nrow(coords) < 2) {
return(NULL) # Skip files with too few points
}
# Convert Timestamp to proper datetime format
coords <- coords %>%
mutate(
Timestamp = ymd_hms(Timestamp), # Convert to POSIXct
Date = as.Date(Timestamp) # Extract Date separately
) %>%
arrange(Timestamp) # Ensure chronological order
# Compute distances between consecutive points
coords <- coords %>%
mutate(
Distance_m = c(0, distHaversine(cbind(Longitude[-n()], Latitude[-n()]),
cbind(Longitude[-1], Latitude[-1]))), # Compute distances
Cumulative_Distance_km = cumsum(Distance_m) / 1000 # Convert meters to km
)
return(coords)
}
# Run the function on all files
all_gpx_data <- map_df(gpx_files, extract_gpx_distance)
# Save data for future use
write.csv(all_gpx_data, "2024_paddling_routes.csv", row.names = FALSE)
# Convert to a spatial object
paddling_sf <- st_as_sf(all_gpx_data, coords = c("Longitude", "Latitude"), crs = 4326)
# Save as GeoJSON
st_write(paddling_sf, "paddling_data.geojson", driver = "GeoJSON", append = FALSE)
And voila! Now I have a GeoJSON file available to make fun maps with!
But first, stats…
| Metric | Value |
|---|---|
| Total Distance Paddled (miles) | 603.93 |
| Total Hours Paddled | 120.41 |
| Number of Sessions | 87 |
| Average Distance per Session (miles) | 6.94 |
| Average Duration per Session (mins) | 83.04 |
| Earliest Start Time of Day | 06:23:11 |
| Latest End Time of Day | 11:27:01 |
On a side note, I don’t believe that last metric - “Latest end time of day” - is correct. If it’s 11pm, that’s wayyy too late to be on the water. If it’s 11am, that’s far too early to be off the water, because most of our practices are in the evenings. I’m sure something just got recorded wrong there.

Quite a normal distribution until we get to change season! And you can bet your ass I did absolutely zero paddling in October. A girl’s gotta rest.